Merge branch 'sortable_events'

Akinori MUSHA 8 years ago
parent
commit
60fa69842e

+ 1 - 0
CHANGES.md

@@ -1,5 +1,6 @@
1 1
 # Changes
2 2
 
3
+* Jul 22, 2015   - DataOutputAgent can configure the order of events in the output via `events_order`.
3 4
 * Jul 20, 2015   - Control Links (used by the SchedularAgent) are correctly exported in Scenarios.
4 5
 * Jul 20, 2015   - keep\_events\_for was moved from days to seconds; Scenarios have a schema verison.
5 6
 * Jul 8, 2015    - DataOutputAgent supports feed icon, and a new template variable `events`.

+ 25 - 14
app/concerns/dry_runnable.rb

@@ -1,10 +1,8 @@
1 1
 module DryRunnable
2
-  def dry_run!
3
-    readonly!
2
+  extend ActiveSupport::Concern
4 3
 
5
-    class << self
6
-      prepend Sandbox
7
-    end
4
+  def dry_run!
5
+    @dry_run = true
8 6
 
9 7
     log = StringIO.new
10 8
     @dry_run_logger = Logger.new(log)
@@ -14,6 +12,7 @@ module DryRunnable
14 12
 
15 13
     begin
16 14
       raise "#{short_type} does not support dry-run" unless can_dry_run?
15
+      readonly!
17 16
       check
18 17
     rescue => e
19 18
       error "Exception during dry-run. #{e.message}: #{e.backtrace.join("\n")}"
@@ -23,28 +22,38 @@ module DryRunnable
23 22
       memory: memory,
24 23
       log: log.string,
25 24
     )
25
+  ensure
26
+    @dry_run = false
26 27
   end
27 28
 
28 29
   def dry_run?
29
-    is_a? Sandbox
30
+    !!@dry_run
31
+  end
32
+
33
+  included do
34
+    prepend Wrapper
30 35
   end
31 36
 
32
-  module Sandbox
37
+  module Wrapper
33 38
     attr_accessor :results
34 39
 
35 40
     def logger
41
+      return super unless dry_run?
36 42
       @dry_run_logger
37 43
     end
38 44
 
39
-    def save
40
-      valid?
45
+    def save(options = {})
46
+      return super unless dry_run?
47
+      perform_validations(options)
41 48
     end
42 49
 
43
-    def save!
44
-      save or raise ActiveRecord::RecordNotSaved
50
+    def save!(options = {})
51
+      return super unless dry_run?
52
+      save(options) or raise_record_invalid
45 53
     end
46 54
 
47 55
     def log(message, options = {})
56
+      return super unless dry_run?
48 57
       case options[:level] || 3
49 58
       when 0..2
50 59
         sev = Logger::DEBUG
@@ -57,10 +66,12 @@ module DryRunnable
57 66
       logger.log(sev, message)
58 67
     end
59 68
 
60
-    def create_event(event_hash)
69
+    def create_event(event)
70
+      return super unless dry_run?
61 71
       if can_create_events?
62
-        @dry_run_results[:events] << event_hash[:payload]
63
-        events.build({ user: user, expires_at: new_event_expiration_date }.merge(event_hash))
72
+        event = build_event(event)
73
+        @dry_run_results[:events] << event.payload
74
+        event
64 75
       else
65 76
         error "This Agent cannot create events!"
66 77
       end

+ 183 - 0
app/concerns/sortable_events.rb

@@ -0,0 +1,183 @@
1
+module SortableEvents
2
+  extend ActiveSupport::Concern
3
+
4
+  included do
5
+    validate :validate_events_order
6
+  end
7
+
8
+  def description_events_order(*args)
9
+    self.class.description_events_order(*args)
10
+  end
11
+
12
+  module ClassMethods
13
+    def can_order_created_events!
14
+      raise if cannot_create_events?
15
+      prepend AutomaticSorter
16
+    end
17
+
18
+    def can_order_created_events?
19
+      include? AutomaticSorter
20
+    end
21
+
22
+    def cannot_order_created_events?
23
+      !can_order_created_events?
24
+    end
25
+
26
+    def description_events_order(events = 'events created in each run')
27
+      <<-MD.lstrip
28
+        To specify the order of #{events}, set `events_order` to an array of sort keys, each of which looks like either `expression` or `[expression, type, descending]`, as described as follows:
29
+
30
+        * _expression_ is a Liquid template to generate a string to be used as sort key.
31
+
32
+        * _type_ (optional) is one of `string` (default), `number` and `time`, which specifies how to evaluate _expression_ for comparison.
33
+
34
+        * _descending_ (optional) is a boolean value to determine if comparison should be done in descending (reverse) order, which defaults to `false`.
35
+
36
+        Sort keys listed earlier take precedence over ones listed later.  For example, if you want to sort articles by the date and then by the author, specify `[["{{date}}", "time"], "{{author}}"]`.
37
+
38
+        Sorting is done stably, so even if all events have the same set of sort key values the original order is retained.  Also, a special Liquid variable `_index_` is provided, which contains the zero-based index number of each event, which means you can exactly reverse the order of events by specifying `[["{{_index_}}", "number", true]]`.
39
+      MD
40
+    end
41
+  end
42
+
43
+  def can_order_created_events?
44
+    self.class.can_order_created_events?
45
+  end
46
+
47
+  def cannot_order_created_events?
48
+    self.class.cannot_order_created_events?
49
+  end
50
+
51
+  def events_order
52
+    options['events_order']
53
+  end
54
+
55
+  module AutomaticSorter
56
+    def check
57
+      return super unless events_order
58
+      sorting_events do
59
+        super
60
+      end
61
+    end
62
+
63
+    def receive(incoming_events)
64
+      return super unless events_order
65
+      # incoming events should be processed sequentially
66
+      incoming_events.each do |event|
67
+        sorting_events do
68
+          super([event])
69
+        end
70
+      end
71
+    end
72
+
73
+    def create_event(event)
74
+      if @sortable_events
75
+        event = build_event(event)
76
+        @sortable_events << event
77
+        event
78
+      else
79
+        super
80
+      end
81
+    end
82
+
83
+    private
84
+
85
+    def sorting_events(&block)
86
+      @sortable_events = []
87
+      yield
88
+    ensure
89
+      events, @sortable_events = @sortable_events, nil
90
+      sort_events(events).each do |event|
91
+        create_event(event)
92
+      end
93
+    end
94
+  end
95
+
96
+  private
97
+
98
+  EXPRESSION_PARSER = {
99
+    'string' => ->string { string },
100
+    'number' => ->string { string.to_f },
101
+    'time'   => ->string { Time.zone.parse(string) },
102
+  }
103
+  EXPRESSION_TYPES = EXPRESSION_PARSER.keys.freeze
104
+
105
+  def validate_events_order
106
+    case order_by = events_order
107
+    when nil
108
+    when Array
109
+      # Each tuple may be either [expression, type, desc] or just
110
+      # expression.
111
+      order_by.each do |expression, type, desc|
112
+        case expression
113
+        when String
114
+          # ok
115
+        else
116
+          errors.add(:base, "first element of each events_order tuple must be a Liquid template")
117
+          break
118
+        end
119
+        case type
120
+        when nil, *EXPRESSION_TYPES
121
+          # ok
122
+        else
123
+          errors.add(:base, "second element of each events_order tuple must be #{EXPRESSION_TYPES.to_sentence(last_word_connector: ' or ')}")
124
+          break
125
+        end
126
+        if !desc.nil? && boolify(desc).nil?
127
+          errors.add(:base, "third element of each events_order tuple must be a boolean value")
128
+          break
129
+        end
130
+      end
131
+    else
132
+      errors.add(:base, "events_order must be an array of arrays")
133
+    end
134
+  end
135
+
136
+  # Sort given events in order specified by the "events_order" option
137
+  def sort_events(events)
138
+    order_by = events_order.presence or
139
+      return events
140
+
141
+    orders = order_by.map { |_, _, desc = false| boolify(desc) }
142
+
143
+    Utils.sort_tuples!(
144
+      events.map.with_index { |event, index|
145
+        interpolate_with(event) {
146
+          interpolation_context['_index_'] = index
147
+          order_by.map { |expression, type, _|
148
+            string = interpolate_string(expression)
149
+            begin
150
+              EXPRESSION_PARSER[type || 'string'.freeze][string]
151
+            rescue
152
+              error "Cannot parse #{string.inspect} as #{type}; treating it as string"
153
+              string
154
+            end
155
+          }
156
+        } << index << event  # index is to make sorting stable
157
+      },
158
+      orders
159
+    ).collect!(&:last)
160
+  end
161
+
162
+  # The emulation of Module#prepend provided by lib/prepend.rb does
163
+  # not work for methods defined after a call of prepend.
164
+  if Module.method(:prepend).source_location
165
+    module ClassMethods
166
+      def can_order_created_events!
167
+        raise if cannot_create_events?
168
+        @can_order_created_events = true
169
+      end
170
+
171
+      def can_order_created_events?
172
+        !!@can_order_created_events
173
+      end
174
+    end
175
+
176
+    def initialize(*args)
177
+      if self.class.instance_variable_get(:@can_order_created_events)
178
+        self.class.__send__ :prepend, SortableEvents::AutomaticSorter
179
+      end
180
+      super
181
+    end
182
+  end
183
+end

+ 12 - 5
app/models/agent.rb

@@ -13,6 +13,7 @@ class Agent < ActiveRecord::Base
13 13
   include HasGuid
14 14
   include LiquidDroppable
15 15
   include DryRunnable
16
+  include SortableEvents
16 17
 
17 18
   markdown_class_attributes :description, :event_description
18 19
 
@@ -104,12 +105,18 @@ class Agent < ActiveRecord::Base
104 105
     raise "Implement me in your subclass"
105 106
   end
106 107
 
107
-  def create_event(attrs)
108
+  def build_event(event)
109
+    event = events.build(event) if event.is_a?(Hash)
110
+    event.user = user
111
+    event.expires_at ||= new_event_expiration_date
112
+    event
113
+  end
114
+
115
+  def create_event(event)
108 116
     if can_create_events?
109
-      events.create!({
110
-         :user => user,
111
-         :expires_at => new_event_expiration_date
112
-      }.merge(attrs))
117
+      event = build_event(event)
118
+      event.save!
119
+      event
113 120
     else
114 121
       error "This Agent cannot create events!"
115 122
     end

+ 6 - 2
app/models/agents/data_output_agent.rb

@@ -40,11 +40,15 @@ module Agents
40 40
               "_contents": "tag contents (can be an object for nesting)"
41 41
             }
42 42
 
43
+        # Ordering events in the output
44
+
45
+        #{description_events_order('events in the output')}
46
+
43 47
         # Liquid Templating
44 48
 
45 49
         In Liquid templating, the following variable is available:
46 50
 
47
-        * `events`: An array of events being output, sorted in descending order up to `events_to_show` in number.  For example, if source events contain a site title in the `site_title` key, you can refer to it in `template.title` by putting `{{events.first.site_title}}`.
51
+        * `events`: An array of events being output, sorted in the given order, up to `events_to_show` in number.  For example, if source events contain a site title in the `site_title` key, you can refer to it in `template.title` by putting `{{events.first.site_title}}`.
48 52
 
49 53
       MD
50 54
     end
@@ -134,7 +138,7 @@ module Agents
134 138
         end
135 139
       end
136 140
 
137
-      source_events = received_events.order(id: :desc).limit(events_to_show).to_a
141
+      source_events = sort_events(received_events.order(id: :desc).limit(events_to_show).to_a)
138 142
 
139 143
       interpolation_context.stack do
140 144
         interpolation_context['events'] = source_events

+ 5 - 0
app/models/agents/website_agent.rb

@@ -6,6 +6,7 @@ module Agents
6 6
     include WebRequestConcern
7 7
 
8 8
     can_dry_run!
9
+    can_order_created_events!
9 10
 
10 11
     default_schedule "every_12h"
11 12
 
@@ -105,6 +106,10 @@ module Agents
105 106
           * `status`: HTTP status as integer. (Almost always 200)
106 107
 
107 108
           * `headers`: Response headers; for example, `{{ _response_.headers.Content-Type }}` expands to the value of the Content-Type header.  Keys are insensitive to cases and -/_.
109
+
110
+      # Ordering Events
111
+
112
+      #{description_events_order}
108 113
     MD
109 114
 
110 115
     event_description do

+ 40 - 0
lib/utils.rb

@@ -79,4 +79,44 @@ module Utils
79 79
   def self.pretty_jsonify(thing)
80 80
     JSON.pretty_generate(thing).gsub('</', '<\/')
81 81
   end
82
+
83
+  class TupleSorter
84
+    class SortableTuple
85
+      attr_reader :array
86
+
87
+      # The <=> method will call orders[n] to determine if the nth element
88
+      # should be compared in descending order.
89
+      def initialize(array, orders = [])
90
+        @array = array
91
+        @orders = orders
92
+      end
93
+
94
+      def <=> other
95
+        other = other.array
96
+        @array.each_with_index do |e, i|
97
+          case cmp = e <=> other[i]
98
+          when nil
99
+            return nil
100
+          when 0
101
+            next
102
+          else
103
+            return @orders[i] ? -cmp : cmp
104
+          end
105
+        end
106
+        0
107
+      end
108
+    end
109
+
110
+    class << self
111
+      def sort!(array, orders = [])
112
+        array.sort_by! do |e|
113
+          SortableTuple.new(e, orders)
114
+        end
115
+      end
116
+    end
117
+  end
118
+
119
+  def self.sort_tuples!(array, orders = [])
120
+    TupleSorter.sort!(array, orders)
121
+  end
82 122
 end

+ 264 - 0
spec/concerns/sortable_events_spec.rb

@@ -0,0 +1,264 @@
1
+require 'spec_helper'
2
+
3
+describe SortableEvents do
4
+  let(:agent_class) {
5
+    Class.new(Agent) do
6
+      include SortableEvents
7
+
8
+      default_schedule 'never'
9
+
10
+      def self.valid_type?(name)
11
+        true
12
+      end
13
+    end
14
+  }
15
+
16
+  def new_agent(events_order = nil)
17
+    options = {}
18
+    options['events_order'] = events_order if events_order
19
+    agent_class.new(name: 'test', options: options) { |agent|
20
+      agent.user = users(:bob)
21
+    }
22
+  end
23
+
24
+  describe 'validations' do
25
+    let(:agent_class) {
26
+      Class.new(Agent) do
27
+        include SortableEvents
28
+
29
+        default_schedule 'never'
30
+
31
+        def self.valid_type?(name)
32
+          true
33
+        end
34
+      end
35
+    }
36
+
37
+    def new_agent(events_order = nil)
38
+      options = {}
39
+      options['events_order'] = events_order if events_order
40
+      agent_class.new(name: 'test', options: options) { |agent|
41
+        agent.user = users(:bob)
42
+      }
43
+    end
44
+
45
+    it 'should allow events_order to be unspecified, null or an empty array' do
46
+      expect(new_agent()).to be_valid
47
+      expect(new_agent(nil)).to be_valid
48
+      expect(new_agent([])).to be_valid
49
+    end
50
+
51
+    it 'should not allow events_order to be a non-array object' do
52
+      agent = new_agent(0)
53
+      expect(agent).not_to be_valid
54
+      expect(agent.errors[:base]).to include(/events_order/)
55
+
56
+      agent = new_agent('')
57
+      expect(agent).not_to be_valid
58
+      expect(agent.errors[:base]).to include(/events_order/)
59
+
60
+      agent = new_agent({})
61
+      expect(agent).not_to be_valid
62
+      expect(agent.errors[:base]).to include(/events_order/)
63
+    end
64
+
65
+    it 'should not allow events_order to be an array containing unexpected objects' do
66
+      agent = new_agent(['{{key}}', 1])
67
+      expect(agent).not_to be_valid
68
+      expect(agent.errors[:base]).to include(/events_order/)
69
+
70
+      agent = new_agent(['{{key1}}', ['{{key2}}', 'unknown']])
71
+      expect(agent).not_to be_valid
72
+      expect(agent.errors[:base]).to include(/events_order/)
73
+    end
74
+
75
+    it 'should allow events_order to be an array containing strings and valid tuples' do
76
+      agent = new_agent(['{{key1}}', ['{{key2}}'], ['{{key3}}', 'number']])
77
+      expect(agent).to be_valid
78
+
79
+      agent = new_agent(['{{key1}}', ['{{key2}}'], ['{{key3}}', 'number'], ['{{key4}}', 'time', true]])
80
+      expect(agent).to be_valid
81
+    end
82
+  end
83
+
84
+  describe 'sort_events' do
85
+    let(:payloads) {
86
+      [
87
+        { 'title' => 'TitleA', 'score' => 4,  'updated_on' => '7 Jul 2015' },
88
+        { 'title' => 'TitleB', 'score' => 2,  'updated_on' => '25 Jun 2014' },
89
+        { 'title' => 'TitleD', 'score' => 10, 'updated_on' => '10 Jan 2015' },
90
+        { 'title' => 'TitleC', 'score' => 10, 'updated_on' => '9 Feb 2015' },
91
+      ]
92
+    }
93
+
94
+    let(:events) {
95
+      payloads.map { |payload| Event.new(payload: payload) }
96
+    }
97
+
98
+    it 'should sort events by a given key' do
99
+      agent = new_agent(['{{title}}'])
100
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleC TitleD])
101
+
102
+      agent = new_agent([['{{title}}', 'string', true]])
103
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleD TitleC TitleB TitleA])
104
+    end
105
+
106
+    it 'should sort events by multiple keys' do
107
+      agent = new_agent([['{{score}}', 'number'], '{{title}}'])
108
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleA TitleC TitleD])
109
+
110
+      agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]])
111
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleA TitleD TitleC])
112
+    end
113
+
114
+    it 'should sort events by time' do
115
+      agent = new_agent([['{{updated_on}}', 'time']])
116
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleB TitleD TitleC TitleA])
117
+    end
118
+
119
+    it 'should sort events stably' do
120
+      agent = new_agent(['<constant>'])
121
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC])
122
+
123
+      agent = new_agent([['<constant>', 'string', true]])
124
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC])
125
+    end
126
+
127
+    it 'should support _index_' do
128
+      agent = new_agent([['{{_index_}}', 'number', true]])
129
+      expect(agent.__send__(:sort_events, events).map { |e| e.payload['title'] }).to eq(%w[TitleC TitleD TitleB TitleA])
130
+    end
131
+  end
132
+
133
+  describe 'automatic event sorter' do
134
+    describe 'declaration' do
135
+      let(:passive_agent_class) {
136
+        Class.new(Agent) do
137
+          include SortableEvents
138
+
139
+          cannot_create_events!
140
+        end
141
+      }
142
+
143
+      let(:active_agent_class) {
144
+        Class.new(Agent) do
145
+          include SortableEvents
146
+        end
147
+      }
148
+
149
+      describe 'can_order_created_events!' do
150
+        it 'should refuse to work if called from an Agent that cannot create events' do
151
+          expect {
152
+            passive_agent_class.class_eval do
153
+              can_order_created_events!
154
+            end
155
+          }.to raise_error
156
+        end
157
+
158
+        it 'should work if called from an Agent that can create events' do
159
+          expect {
160
+            active_agent_class.class_eval do
161
+              can_order_created_events!
162
+            end
163
+          }.not_to raise_error
164
+        end
165
+      end
166
+
167
+      describe 'can_order_created_events?' do
168
+        it 'should return false unless an Agent declares can_order_created_events!' do
169
+          expect(active_agent_class.can_order_created_events?).to eq(false)
170
+          expect(active_agent_class.new.can_order_created_events?).to eq(false)
171
+        end
172
+
173
+        it 'should return true if an Agent declares can_order_created_events!' do
174
+          active_agent_class.class_eval do
175
+            can_order_created_events!
176
+          end
177
+
178
+          expect(active_agent_class.can_order_created_events?).to eq(true)
179
+          expect(active_agent_class.new.can_order_created_events?).to eq(true)
180
+        end
181
+      end
182
+    end
183
+
184
+    describe 'behavior' do
185
+      class Agents::EventOrderableAgent < Agent
186
+        include SortableEvents
187
+
188
+        default_schedule 'never'
189
+
190
+        can_order_created_events!
191
+
192
+        attr_accessor :payloads_to_emit
193
+
194
+        def self.valid_type?(name)
195
+          true
196
+        end
197
+
198
+        def check
199
+          payloads_to_emit.each do |payload|
200
+            create_event payload: payload
201
+          end
202
+        end
203
+
204
+        def receive(events)
205
+          events.each do |event|
206
+            payloads_to_emit.each do |payload|
207
+              create_event payload: payload.merge('title' => payload['title'] + event.payload['title_suffix'])
208
+            end
209
+          end
210
+        end
211
+      end
212
+
213
+      def new_agent(events_order = nil)
214
+        options = {}
215
+        options['events_order'] = events_order if events_order
216
+        Agents::EventOrderableAgent.new(name: 'test', options: options) { |agent|
217
+          agent.user = users(:bob)
218
+          agent.payloads_to_emit = payloads
219
+        }
220
+      end
221
+
222
+      let(:payloads) {
223
+        [
224
+          { 'title' => 'TitleA', 'score' => 4,  'updated_on' => '7 Jul 2015' },
225
+          { 'title' => 'TitleB', 'score' => 2,  'updated_on' => '25 Jun 2014' },
226
+          { 'title' => 'TitleD', 'score' => 10, 'updated_on' => '10 Jan 2015' },
227
+          { 'title' => 'TitleC', 'score' => 10, 'updated_on' => '9 Feb 2015' },
228
+        ]
229
+      }
230
+
231
+      it 'should keep the order of created events unless events_order is specified' do
232
+        [[], [nil], [[]]].each do |args|
233
+          agent = new_agent(*args)
234
+          agent.save!
235
+          expect { agent.check }.to change { Event.count }.by(4)
236
+          events = agent.events.last(4).sort_by(&:id)
237
+          expect(events.map { |event| event.payload['title'] }).to eq(%w[TitleA TitleB TitleD TitleC])
238
+        end
239
+      end
240
+
241
+      it 'should sort events created in check() in the order specified in events_order' do
242
+        agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]])
243
+        agent.save!
244
+        expect { agent.check }.to change { Event.count }.by(4)
245
+        events = agent.events.last(4).sort_by(&:id)
246
+        expect(events.map { |event| event.payload['title'] }).to eq(%w[TitleB TitleA TitleD TitleC])
247
+      end
248
+
249
+      it 'should sort events created in receive() in the order specified in events_order' do
250
+        agent = new_agent([['{{score}}', 'number'], ['{{title}}', 'string', true]])
251
+        agent.save!
252
+        expect {
253
+          agent.receive([Event.new(payload: { 'title_suffix' => ' [new]' }),
254
+                         Event.new(payload: { 'title_suffix' => ' [popular]' })])
255
+        }.to change { Event.count }.by(8)
256
+        events = agent.events.last(8).sort_by(&:id)
257
+        expect(events.map { |event| event.payload['title'] }).to eq([
258
+          'TitleB [new]',     'TitleA [new]',     'TitleD [new]',     'TitleC [new]',
259
+          'TitleB [popular]', 'TitleA [popular]', 'TitleD [popular]', 'TitleC [popular]',
260
+        ])
261
+      end
262
+    end
263
+  end
264
+end

+ 40 - 0
spec/lib/utils_spec.rb

@@ -114,4 +114,44 @@ describe Utils do
114 114
       expect(cleaned_json).to include("<\\/script>")
115 115
     end
116 116
   end
117
+
118
+  describe "#sort_tuples!" do
119
+    let(:tuples) {
120
+      time = Time.now
121
+      [
122
+        [2, "a", time - 1],  # 0
123
+        [2, "b", time - 1],  # 1
124
+        [1, "b", time - 1],  # 2
125
+        [1, "b", time],      # 3
126
+        [1, "a", time],      # 4
127
+        [2, "a", time + 1],  # 5
128
+        [2, "a", time],      # 6
129
+      ]
130
+    }
131
+
132
+    it "sorts tuples like arrays by default" do
133
+      expected = tuples.values_at(4, 2, 3, 0, 6, 5, 1)
134
+
135
+      Utils.sort_tuples!(tuples)
136
+      expect(tuples).to eq expected
137
+    end
138
+
139
+    it "sorts tuples in order specified: case 1" do
140
+      # order by x1 asc, x2 desc, c3 asc
141
+      orders = [false, true, false]
142
+      expected = tuples.values_at(2, 3, 4, 1, 0, 6, 5)
143
+
144
+      Utils.sort_tuples!(tuples, orders)
145
+      expect(tuples).to eq expected
146
+    end
147
+
148
+    it "sorts tuples in order specified: case 2" do
149
+      # order by x1 desc, x2 asc, c3 desc
150
+      orders = [true, false, true]
151
+      expected = tuples.values_at(5, 6, 0, 1, 4, 3, 2)
152
+
153
+      Utils.sort_tuples!(tuples, orders)
154
+      expect(tuples).to eq expected
155
+    end
156
+  end
117 157
 end

+ 16 - 0
spec/models/agents/data_output_agent_spec.rb

@@ -209,6 +209,22 @@ describe Agents::DataOutputAgent do
209 209
         })
210 210
       end
211 211
 
212
+      describe 'ordering' do
213
+        before do
214
+          agent.options['events_order'] = ['{{title}}']
215
+        end
216
+
217
+        it 'can reorder the events_to_show last events based on a Liquid expression' do
218
+          asc_content, _status, _content_type = agent.receive_web_request({ 'secret' => 'secret2' }, 'get', 'application/json')
219
+          expect(asc_content['items'].map {|i| i["title"] }).to eq(["Evolving", "Evolving again", "Evolving yet again with a past date"])
220
+
221
+          agent.options['events_order'] = [['{{title}}', 'string', true]]
222
+
223
+          desc_content, _status, _content_type = agent.receive_web_request({ 'secret' => 'secret2' }, 'get', 'application/json')
224
+          expect(desc_content['items']).to eq(asc_content['items'].reverse)
225
+        end
226
+      end
227
+
212 228
       describe "interpolating \"events\"" do
213 229
         before do
214 230
           agent.options['template']['title'] = "XKCD comics as a feed{% if events.first.site_title %} ({{events.first.site_title}}){% endif %}"